ZgRNS Git Node
Mirrored from https://github.com/varna9000/micropython-reticulum.git synced 10h ago
π Files β’ βΈ Work (0) β’ πΉ Commits (126) β’ β Branches (2) β’ β Tags (0) β’ β₯ Thanks (2) β’ π Stats
>Β΅Reticulum
!demo
Wire-compatible with reference Reticulum β Β΅Reticulum nodes appear as normal peers in MeshChat / Sideband / NomadNet, with full LXMF messaging support and delivery receipts.
What can I build with this?
Each row below is a working example in the T383838firmware/ folder. Pick the one closest to what you want, then follow the matching walkthrough further down.
ββββββββββββββββββββββββββββββββββββ¬ββββββ¬βββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββ
β Goal β Exβ¦ β Hardware β Connectivity β
ββββββββββββββββββββββββββββββββββββΌββββββΌβββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββ€
β Chat with friends from a cheap β¦ β _ β macOS, Windows, Linux, web β Chaβ¦ β
β Sideband β Android, Linux β Mobβ¦ β
β NomadNet β Terminal (Linux/macOS) β Broβ¦ β
β Reticulum / RNS β Python β Theβ¦ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββ΄βββββββ
If you only install one, install MeshChat β your node will appear in its peer list within a few seconds of booting on the same LAN.
Installation
Step 1 β Flash MicroPython to your board
You have two choices of firmware. Pick the one that matches what you want to build:
βββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ¬ββββββ
β Firmware β Use when β Doβ¦ β
βββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββΌββββββ€
β Standard MicroPython 1.22+ β All examples except the camera node β _ and open it.
2. Plug your ESP32-S3 into USB. Hold the BOOT button while plugging in if the board doesn't enter download mode automatically.
3. In Thonny, go to Tools β Options β Interpreter.
4. Set Interpreter to MicroPython (ESP32) and pick your USB port.
5. Click Install or update MicroPython (bottom right).
6. In the dialog, choose:
β’ Target port: your board's port
β’ MicroPython family: T383838ESP32-S3
β’ Variant: matching your board (use T383838Espressif β’ ESP32-S3 for generic S3 boards like the Waveshare S3-Zero)
β’ Version: latest stable
β’ For the camera firmware, click Select local MicroPython image instead and point Thonny at the T383838.bin you downloaded from the camera-API releases page.
7. Click Install and wait until it finishes. Close the dialog. Thonny is now connected to the REPL.
Option B β esptool (command line)
Install esptool once (T383838pip install esptool), then with your board in download mode (hold BOOT while plugging USB):
T282828
T8b949e# Wipe existing firmware first (recommended, especially when switching builds)
esptool.py --chip esp32s3 --port /dev/ttyACM0 erase_flash
T8b949e# Standard MicroPython
esptool.py --chip esp32s3 --port /dev/ttyACM0 write_flash -z T79c0ff0 ESP32_GENERIC_S3-<version>.bin
T8b949e# OR: camera-enabled MicroPython
esptool.py --chip esp32s3 --port /dev/ttyACM0 write_flash -z T79c0ff0 firmware_camera_esp32s3.bin
Replace T383838/dev/ttyACM0 with your port (T383838COM3 on Windows, T383838/dev/tty.usbmodem* on macOS). After flashing, unplug and re-plug the board to exit download mode.
Step 2 β Upload the firmware files
Upload the contents of the T383838firmware/ folder to the root of the microcontroller's filesystem. The T383838lib/ folder is included β it contains the native crypto and bz2 modules that make message delivery ~160Γ faster than pure Python. Don't skip it.
With Thonny: in the Files pane, drag every file and folder inside T383838firmware/ onto the device's root.
With mpremote (T383838pip install mpremote):
T282828
mpremote cp -r firmware/ :
Step 3 β Configure WiFi
Edit T383838config.py on the device and set:
T282828
Te6edf3WIFI_SSID Tff7b72= Ta5d6ff"Ta5d6ffYourNetworkTa5d6ff"
Te6edf3WIFI_PASS Tff7b72= Ta5d6ff"Ta5d6ffYourPasswordTa5d6ff"
Te6edf3NODE_NAME Tff7b72= Ta5d6ff"Ta5d6ffMyNodeTa5d6ff"
If you'll use LoRa or TCP instead of WiFi, also enable the matching block in the T383838CONFIG["interfaces"] list. See Interfaces below for the full reference.
Step 4 β Run
In the Thonny REPL (or T383838mpremote repl):
T282828
Tff7b72import T7ee787example_node
You should see boot output ending in something like:
T282828
[reticulum] Identity loaded: <hex hash>
[reticulum] WiFi UDP interface up on 192.168.1.42
[reticulum] Announced as MyNode
Open MeshChat on the same LAN β your node will appear as a peer within a few seconds.
To run a different example, replace T383838example_node with T383838example_nomadnet_node, T383838example_camera_node, T383838example_sensor, or T383838example_proxy. To make any example run on boot, save it as T383838main.py on the device.
Example walkthroughs
1 β Chat node (T383838example_node.py)
The default example. The node receives LXMF messages, echoes them back, and can drive the onboard NeoPixel from chat commands.
β’ Hardware: any supported ESP32-S3 board (WiFi). The onboard NeoPixel is on GPIO 21 on the Waveshare S3-Zero.
β’ Firmware: standard MicroPython.
β’ Config: WiFi SSID/password + a WiFi interface enabled in T383838CONFIG["interfaces"].
β’ Usage from MeshChat:
βββββββββββββββββ¬ββββββββββββββββββββββββ
β You send β What happens β
βββββββββββββββββΌββββββββββββββββββββββββ€
β T383838red β Onboard LED turns red β
β T383838green β LED turns green β
β T383838blue β LED turns blue β
β T383838off β LED turns off β
β anything else β Echoed back to you β
βββββββββββββββββ΄ββββββββββββββββββββββββ
Commands are case-insensitive. The same pattern can drive relays, motors, or any GPIO-attached hardware β see the gpio_control peripheral.
2 β NomadNet page server (T383838example_nomadnet_node.py)
Serves micron-format pages over Reticulum Links. Think of it as a tiny web server reachable only via Reticulum.
β’ Hardware: any supported ESP32-S3 board. Optional sensors connected via IΒ²C, UART, or ADC.
β’ Firmware: standard MicroPython.
β’ Config: WiFi (or LoRa / TCP) interface enabled.
β’ How to use: open NomadNet or MeshChat on another machine, wait for the announce as T383838nomadnetwork.node, then browse to the node. The default landing page is T383838firmware/pages/index.mu. Drop more T383838.mu files into T383838firmware/pages/ to add more pages.
Template variables you can use inside a T383838.mu page:
βββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Variable β Example output β
βββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β T383838{node_name} β T383838MyNode β
β T383838{mem_free} β T3838387.6 MB β
β T383838{uptime} β T3838382h 15m 30s β
β T383838{sensor} β T383838Temperature: 24.44C, Pressure: 995.45hPa, Humidity: 100.00% β
βββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Pages under ~417 bytes ride a single encrypted link packet. Larger pages and downloadable files (up to 16 KB) are transferred automatically via the Resource protocol. Files dropped into T383838firmware/files/ are served at T383838/file/<name> β link to them from a page with BT383838 [label:/file/name] .
3 β Camera node (T383838example_camera_node.py)
Captures a photo with an OV2640 and ships it back as an LXMF image attachment. By
default it sends a VGA (640 Γ 480) WebP (~7 KB) β small enough for LoRa yet far
higher resolution than a same-size JPEG β and falls back to JPEG if the WebP encoder
isn't installed.
β’ Hardware: an ESP32-S3-CAM board with an OV2640 camera + PSRAM.
β’ Firmware: camera-enabled MicroPython is mandatory (see Step 1). Standard MicroPython will fail at T383838import camera.
β’ Config: WiFi creds.
β’ How to use: send T383838image from MeshChat / Sideband to get a photo back (delivered via a Link + Resource transfer). Send T383838help or T383838settings to see the live controls.
β’ Image format & quality: resolution, WebP quality, downscale, exposure, flash and night mode are all adjustable at runtime by messaging the node β see Camera image settings (JPEG & WebP) for every parameter and the size/quality trade-offs.
β’ WebP encoder: the optional native module T383838webp_fast.mpy in T383838/lib (built from tools/natmod/webp_fast/). Without it the node simply sends JPEG. Note: builds before July 2026 swapped red/blue in every image (pink skies, blue foliage) β a BGR/RGB mismatch in the JPEG decoder, since fixed; update T383838/lib/webp_fast_xtensawin.mpy if your photos look like that.
For direct (non-LXMF) capture you can also use the peripheral module from the REPL:
T282828
Tff7b72from T7ee787peripheralsT7ee787.T7ee787camera Tff7b72import Te6edf3capture
T8b949e# Save to flash
Te6edf3captureTb4b4b4(Te6edf3resolutionTff7b72=Ta5d6ff"Ta5d6ffcifTa5d6ff"Tb4b4b4, Te6edf3qualityTff7b72=T79c0ff30Tb4b4b4)
T8b949e# In-memory only (for LXMF transmission)
Te6edf3img_bytes Tff7b72= Te6edf3captureTb4b4b4(Te6edf3pathTff7b72=Tff7b72NoneTb4b4b4, Te6edf3resolutionTff7b72=Ta5d6ff"Ta5d6ffqvgaTa5d6ff"Tb4b4b4, Te6edf3qualityTff7b72=T79c0ff15Tb4b4b4)
Available resolutions: T383838qqvga (160 Γ 120), T383838qvga (320 Γ 240), T383838cif (400 Γ 296), T383838hvga (480 Γ 320), T383838vga (640 Γ 480), and several larger options.
4 β Sensor node with deepsleep (T383838example_sensor.py)
A minimal LXMF client that boots, takes a reading, sends it to a fixed "hub" address, and goes back to deepsleep on a timer. Battery-friendly.
β’ Hardware: any supported ESP32-S3 + sensor (e.g. BME280 on IΒ²C, SDS011 on UART).
β’ Firmware: standard MicroPython.
β’ Config: WiFi creds + T383838SENSOR_HUB = "<hex destination hash>" in T383838config.py (the LXMF address of your collector node β copy it from your collector's boot log or MeshChat).
β’ How to use: T383838import example_sensor. Save as T383838main.py for automatic restart-on-wake.
The deepsleep timer is set inside the example; change it to suit your duty cycle.
5 β USB serial β LoRa chat bridge (T383838example_proxy.py)
Turns an RP2040 with an attached LoRa radio (E32 or SX1262) into a transparent USB-to-Reticulum bridge β chat over LoRa from any laptop without installing MeshChat.
β’ Hardware: RP2040 (e.g. Waveshare RP2040-Zero) + a LoRa interface.
β’ Firmware: standard MicroPython for RP2040.
β’ Config: LoRa interface enabled in T383838CONFIG["interfaces"].
β’ How to use: plug the RP2040 into your laptop, open a serial terminal (T383838screen, T383838minicom, T383838tio, T383838PuTTY) on its USB CDC port. Type to chat.
βββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββββ
β Command β Effect β
βββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββββ€
β T383838/help β Show available commands β
β T383838/peers β List known peers β
β T383838/to <hex> β Set the current chat target by hex-hash prefix β
β T383838/me β Show this node's LXMF address β
β T383838/name β Show this node's display name β
β T383838/announce β Broadcast identity now β
β T383838/quit β Shutdown and return to the MicroPython REPL β
βββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββββββ
Anything that doesn't start with T383838/ is sent as an LXMF chat message to the current target. The current target auto-switches to the most recent peer who messaged you, so replies are automatic.
Interfaces
Every interface is configured by an entry in the T383838CONFIG["interfaces"] list in T383838config.py. You can run multiple interfaces at the same time (e.g. WiFi + LoRa). The auto-generated T383838/rns/config.json on the device mirrors this β you usually don't need to edit it by hand.
WiFi UDP
The default. Broadcasts on the local LAN and pairs with MeshChat / Sideband automatically.
T282828
Tb4b4b4{
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffUDPInterfaceTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffWiFi UDPTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffenabledTa5d6ff"Tb4b4b4: Tff7b72TrueTb4b4b4,
Ta5d6ff"Ta5d6fflisten_ipTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ff0.0.0.0Ta5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6fflisten_portTa5d6ff"Tb4b4b4: T79c0ff4242Tb4b4b4,
Ta5d6ff"Ta5d6ffforward_ipTa5d6ff"Tb4b4b4: Tff7b72NoneTb4b4b4, T8b949e# None = auto-detected subnet broadcast
Ta5d6ff"Ta5d6ffforward_portTa5d6ff"Tb4b4b4: T79c0ff4242Tb4b4b4,
Tb4b4b4}
Serial (RNode / generic LoRa)
HDLC-framed UART. Use this for an RNode device, a generic LoRa modem in transparent serial mode, or board-to-board wired links.
T282828
Tb4b4b4{
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffSerialInterfaceTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffSerial LinkTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffenabledTa5d6ff"Tb4b4b4: Tff7b72TrueTb4b4b4,
Ta5d6ff"Ta5d6ffuart_idTa5d6ff"Tb4b4b4: T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6fftx_pinTa5d6ff"Tb4b4b4: T79c0ff17Tb4b4b4,
Ta5d6ff"Ta5d6ffrx_pinTa5d6ff"Tb4b4b4: T79c0ff16Tb4b4b4,
Ta5d6ff"Ta5d6ffspeedTa5d6ff"Tb4b4b4: T79c0ff115200Tb4b4b4,
Tb4b4b4}
SX1262 SPI LoRa interface (e.g. XIAO ESP32-S3 + Wio-SX1262)
Native SPI talk to the SX1262 radio. No external serial module needed.
Prerequisite: install the LoRa driver on the device once:
T282828
mpremote mip install lora-sx126x lora-sync
Board pinout presets. The board's wiring (SPI + control pins, TCXO, regulator) lives in T383838firmware/lora_boards.py as named presets β you reference one with a T383838"board" key and keep only the network/radio parameters in the interface entry:
T282828
Tb4b4b4{
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffLoRaInterfaceTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffboardTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffesp32s3_cam_sx1262Ta5d6ff"Tb4b4b4, T8b949e# pinout preset (lora_boards.py)
Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffLoRa SX1262Ta5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffenabledTa5d6ff"Tb4b4b4: Tff7b72TrueTb4b4b4,
Ta5d6ff"Ta5d6fffreq_khzTa5d6ff"Tb4b4b4: T79c0ff868800Tb4b4b4,
Ta5d6ff"Ta5d6ffsfTa5d6ff"Tb4b4b4: T79c0ff8Tb4b4b4,
Ta5d6ff"Ta5d6ffbwTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ff125Ta5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffcoding_rateTa5d6ff"Tb4b4b4: T79c0ff5Tb4b4b4,
Ta5d6ff"Ta5d6fftx_powerTa5d6ff"Tb4b4b4: T79c0ff14Tb4b4b4,
Ta5d6ff"Ta5d6ffsyncwordTa5d6ff"Tb4b4b4: T79c0ff0x1424Tb4b4b4,
Tb4b4b4}
The pins are merged in at startup. Any pin set explicitly on the interface overrides the preset, so you can tweak one pin without editing T383838lora_boards.py.
Built-in presets:
ββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββ
β `BT383838`Fdddboard`f`b β Hardware β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββ€
β T383838xiao_esp32s3_sx1262 β Seeed XIAO ESP32-S3 + Wio-SX1262 (kit) β
β T383838xiao_esp32s3_sx1262_header β XIAO ESP32-S3 + Wio-SX1262 (header board) β
β T383838esp32s3_cam_sx1262 β ESP32-S3 WROOM CAM module + Wio-SX1262 β
ββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββ
Adding a board: add one entry to T383838LORA_BOARDS in T383838firmware/lora_boards.py with that board's T383838sck/mosi/miso/cs/busy/dio1/reset pins (plus T383838dio2_rf_sw, T383838dio3_tcxo_millivolts, and optionally T383838use_dcdc / T383838spi_baudrate), then point an interface at it by name. Radio params stay in T383838config.py so every node on the mesh shares them.
Radio parameters (interface entry β must match across the whole mesh)
β’ T383838freq_khz: 868000 (EU), 915000 (US), 923000 (AS).
β’ T383838sf: 7β12 (higher = longer range, slower).
β’ T383838bw: T383838"125" / T383838"250" / T383838"500" (lower = longer range, slower).
β’ T383838tx_power: -9 to +22 dBm.
β’ T383838syncword: T3838380x1424 β Reticulum/RNode-compatible.
β’ T383838dio2_rf_sw: T383838True on Wio-SX1262 (radio drives DIO2 as RF switch internally).
β’ T383838dio3_tcxo_millivolts: T3838381800 on Wio-SX1262 (TCXO). T383838None to disable (crystal-only modules).
β’ T383838lbt_rssi: CSMA/listen-before-talk busy threshold in dBm (default T383838-100, T383838None disables). Every frame TX first probes the channel and defers in short random slots while it's busy β same etiquette as RNode firmware, essential when a repeater shares the channel.
β’ T383838lbt_max_ms: max LBT wait before transmitting anyway (default T3838382000).
The receive path is also hardened against transparent repeaters (devices that re-transmit every frame verbatim): duplicate halves of split packets (>254 B) are detected and dropped instead of corrupting reassembly.
E32 LoRa interface (EByte E32-900T20D)
Transparent serial LoRa module (product page) with HDLC framing, AUX flow control, and optional auto-configuration of the module's hex registers.
T282828
Tb4b4b4{
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffE32InterfaceTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffLoRa E32Ta5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffenabledTa5d6ff"Tb4b4b4: Tff7b72TrueTb4b4b4,
Ta5d6ff"Ta5d6ffuart_idTa5d6ff"Tb4b4b4: T79c0ff1Tb4b4b4,
Ta5d6ff"Ta5d6fftx_pinTa5d6ff"Tb4b4b4: T79c0ff4Tb4b4b4,
Ta5d6ff"Ta5d6ffrx_pinTa5d6ff"Tb4b4b4: T79c0ff5Tb4b4b4,
Ta5d6ff"Ta5d6ffspeedTa5d6ff"Tb4b4b4: T79c0ff9600Tb4b4b4,
Ta5d6ff"Ta5d6ffm0_pinTa5d6ff"Tb4b4b4: T79c0ff15Tb4b4b4,
Ta5d6ff"Ta5d6ffm1_pinTa5d6ff"Tb4b4b4: T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6ffaux_pinTa5d6ff"Tb4b4b4: T79c0ff6Tb4b4b4,
Ta5d6ff"Ta5d6ffauto_configureTa5d6ff"Tb4b4b4: Tff7b72FalseTb4b4b4,
Ta5d6ff"Ta5d6fftimeoutTa5d6ff"Tb4b4b4: T79c0ff3000Tb4b4b4,
Ta5d6ff"Ta5d6ffchannelTa5d6ff"Tb4b4b4: T79c0ff6Tb4b4b4,
Ta5d6ff"Ta5d6ffair_rateTa5d6ff"Tb4b4b4: T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6fftx_powerTa5d6ff"Tb4b4b4: T79c0ff3Tb4b4b4,
Tb4b4b4}
Parameters
β’ T383838channel: freq = 862 + channel MHz. Channel 6 = 868 MHz (EU ISM), 60 = 922 MHz (US ISM).
β’ T383838air_rate: 0 = 300 bps, 1 = 1200, 2 = 2400 (default), 3 = 4800, 4 = 9600, 5 = 19200.
β’ T383838tx_power: 0 = 20 dBm, 1 = 17 dBm, 2 = 14 dBm, 3 = 10 dBm.
β’ T383838auto_configure: T383838True writes the channel/rate/power registers to the module's flash at boot. Set T383838False once the module is configured.
β’ T383838timeout: HDLC frame timeout in ms. Must be >2Γ the air time of a full packet. At 2400 bps a 182-byte announce takes ~760 ms, so 3000 ms is safe.
Wiring (Waveshare RP2040-Zero example)
βββββββββββ¬ββββββββββββββ¬ββββββββββββββββββββ
β E32 Pin β Function β RP2040 GPIO β
βββββββββββΌββββββββββββββΌββββββββββββββββββββ€
β RXD β Module RX β GPIO 4 (UART1 TX) β
β TXD β Module TX β GPIO 5 (UART1 RX) β
β M0 β Mode select β GPIO 15 β
β M1 β Mode select β GPIO 2 β
β AUX β Busy signal β GPIO 6 β
β VCC β Power β 5 V β
β GND β Ground β GND β
βββββββββββ΄ββββββββββββββ΄ββββββββββββββββββββ
Pin gotcha: on RP2040, do not use UART1 alternate-function pins (GPIO 3, 6, 7, 8) for M0/M1 β UART1 init claims them for CTS/RTS/TX and the resulting contention can damage the GPIO drivers. The driver also sets M0/M1 to 12 mA drive strength (vs the 4 mA default) so the E32's internal pull-ups release reliably.
Power gotcha: the E32-900T20D draws ~120 mA at 20 dBm TX. On RP2040-Zero this current spike will crash the MCU even off the 5 V USB rail. Use T383838tx_power: 3 (10 dBm, ~40 mA) unless the E32 has its own supply with decoupling.
TCP client
Connects to a remote RNS TCP transport server. HDLC framing, wire-compatible with reference Reticulum's T383838TCPServerInterface. Auto-reconnects on disconnect.
T282828
Tb4b4b4{
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffTCPClientInterfaceTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffTransport HubTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffenabledTa5d6ff"Tb4b4b4: Tff7b72TrueTb4b4b4,
Ta5d6ff"Ta5d6fftarget_hostTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffrn.example.comTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6fftarget_portTa5d6ff"Tb4b4b4: T79c0ff4243Tb4b4b4,
Tb4b4b4}
IFAC (Interface Access Codes)
Add T383838networkname and/or T383838passphrase to any interface to require authentication. Both sides must use identical values.
T282828
Tb4b4b4{
Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffTCPClientInterfaceTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffnameTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffAuthenticated TCPTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffenabledTa5d6ff"Tb4b4b4: Tff7b72TrueTb4b4b4,
Ta5d6ff"Ta5d6fftarget_hostTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffrn.example.comTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6fftarget_portTa5d6ff"Tb4b4b4: T79c0ff4243Tb4b4b4,
Ta5d6ff"Ta5d6ffnetworknameTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffmy_networkTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffpassphraseTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffmy_secret_passphraseTa5d6ff"Tb4b4b4,
Tb4b4b4}
The optional T383838ifac_size (default 16 bytes) controls the IFAC tag length and must match the server.
Transport mode (full routing β turn a node into a relay)
Set T383838enable_transport: True and the node becomes a Reticulum transport router: it forwards traffic between its interfaces so a LoRa-only mesh reaches the wider network and back. It is wire-compatible with reference RNS β a Β΅Reticulum router can sit transparently in a path between reference RNS, MeshChat, Sideband or NomadNet nodes.
This is directed routing, not blind flooding: the node learns routes from announces and forwards each packet on the one correct interface toward its destination, extending range without saturating the mesh. example_transport_router.py is a ready-made LoRa β WiFi/TCP router built on it.
T282828
Te6edf3CONFIG Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6ffenable_transportTa5d6ff"Tb4b4b4: Tff7b72TrueTb4b4b4, T8b949e# this node relays for others
Ta5d6ff"Ta5d6ffinterfacesTa5d6ff"Tb4b4b4: Tb4b4b4[
Tb4b4b4{ Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffLoRaInterfaceTa5d6ff"Tb4b4b4, Tff7b72.Tff7b72.Tff7b72. Tb4b4b4}Tb4b4b4, T8b949e# the LoRa mesh side
Tb4b4b4{ Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffTCPClientInterfaceTa5d6ff"Tb4b4b4, Tff7b72.Tff7b72.Tff7b72. Tb4b4b4}Tb4b4b4, T8b949e# the IP side (rnsd / MeshChat); or a UDPInterface
Tb4b4b4]Tb4b4b4,
Tb4b4b4}
What it carries β everything, multi-hop, wire-compatible:
ββββββββββββββββββββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββββββ
β Traffic β How the router handles it β
ββββββββββββββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Announces β re-broadcast with the router's transport id stβ¦ β
β Opportunistic messages (single-packet LXMF) β directed forward to the next-hop interface viaβ¦ β
β Link sessions (MeshChat / Sideband / NomadNet) β a link table is built from the transit T383838LINKREQβ¦ β
β Resource transfers (large messages, β€ 16 KB) β ride the link table automatically β
β Path requests β answered on demand by replaying the cached annβ¦ β
ββββββββββββββββββββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββββ
Routing state lives in RAM-bounded tables: T383838path_table (dest β next-hop + interface + hop count), T383838reverse_table (proof return), T383838link_table (link/resource transit), plus a small cache of recent announces.
Choosing between equal paths β when the same announce reaches a node over two interfaces at the same hop count, whichever copy arrived first would otherwise keep the path forever. Set T383838gravity on an interface to express a preference (RNS 1.4.1 semantics: higher wins, T3838380 is neutral, negatives discouraged). T383838default_gravity at the top level applies to every interface that does not set its own.
T282828
Te6edf3CONFIG Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6ffdefault_gravityTa5d6ff"Tb4b4b4: T79c0ff0Tb4b4b4,
Ta5d6ff"Ta5d6ffinterfacesTa5d6ff"Tb4b4b4: Tb4b4b4[
Tb4b4b4{ Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffTCPClientInterfaceTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffgravityTa5d6ff"Tb4b4b4: T79c0ff5Tb4b4b4, Tff7b72.Tff7b72.Tff7b72. Tb4b4b4}Tb4b4b4, T8b949e# prefer IP when both work
Tb4b4b4{ Ta5d6ff"Ta5d6fftypeTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffLoRaInterfaceTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffgravityTa5d6ff"Tb4b4b4: T79c0ff0Tb4b4b4, Tff7b72.Tff7b72.Tff7b72. Tb4b4b4}Tb4b4b4, T8b949e# fall back to radio
Tb4b4b4]Tb4b4b4,
Tb4b4b4}
Gravity only breaks ties. It never buys a longer path β a shorter route always wins first, regardless of preference.
Resilience (built for an open, long-running mesh): routing tables expire and are purged when an interface drops (WiFi-flap recovery); per-source announce rate-limiting and hard table caps prevent runaway memory; optional strict link-proof validation (native-gated Ed25519, ~17 ms); blackholing of misbehaving identities; and the path table persists to flash so a reboot isn't a mesh blackout.
Watching it work: every forward logs a T383838Relay β¦ line at T383838NOTICE and bumps a counter, so you can follow relay activity in the console. The router example also serves a plain-HTTP dashboard on the LAN (webmonitor.py) showing live T383838RELAYED ann/data/link/proof counts, the path table, and the log stream. Path-table rows are labeled with the peer's announced display name and the protocol behind each destination hash β T383838lxmf (messaging peer), T383838lxmf-pn (propagation node), T383838nomad (NomadNet pages), T383838voice-lxst / T383838voice-mc (LXST and MeshChat call endpoints), T383838probe, or a T383838?hex tag for unknown apps. Classification reads the T383838name_hash every announce carries (no decryption involved) and survives reboots by recomputing labels from persisted identities.
Running it headless: a transport router usually runs without a USB cable, so it wants WiFi up at boot and a way back in to control it. boot.py can bring up WiFi + WebREPL automatically on every reset β but it ships commented out, so a plain leaf node (LoRa-only, sensor, proxy) boots straight to the REPL instead of sitting through a needless ~15 s WiFi connect. Uncomment the execution block at the bottom of T383838boot.py only on a transport node; you can then reach it at T383838ws://<node-ip>:8266/ (log in with T383838WEBREPL_PASSWORD from T383838config.py) to start/stop the router and push fixes over the air.
β A transport router wants the RAM headroom of an ESP32-S3 (PSRAM is ideal). Forwarding is
β single-instance (no shared-instance or tunnel interfaces) β most useful as a LoRa β IP gateway.
Probe responder (rnprobe)
Expose a dedicated destination that replies to T383838rnprobe, the reference reachability/RTT tool. Useful for debugging transport paths.
T282828
Te6edf3CONFIG Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6ffprobeTa5d6ff"Tb4b4b4: Tb4b4b4{
Ta5d6ff"Ta5d6ffenabledTa5d6ff"Tb4b4b4: Tff7b72TrueTb4b4b4,
Ta5d6ff"Ta5d6ffapp_nameTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffurnsTa5d6ff"Tb4b4b4, T8b949e# full_name = "urns.probe"
Ta5d6ff"Ta5d6ffaspectTa5d6ff"Tb4b4b4: Ta5d6ff"Ta5d6ffprobeTa5d6ff"Tb4b4b4,
Ta5d6ff"Ta5d6ffannounce_intervalTa5d6ff"Tb4b4b4: T79c0ff60 Tff7b72* T79c0ff60Tb4b4b4, T8b949e# 1 hour; 0 = announce once at boot only
Tb4b4b4}Tb4b4b4,
Ta5d6ff"Ta5d6ffinterfacesTa5d6ff"Tb4b4b4: Tb4b4b4[Tff7b72.Tff7b72.Tff7b72.Tb4b4b4]Tb4b4b4,
Tb4b4b4}
When enabled the boot log prints the destination hash and full name:
T282828
Probe address: 4a1b⦠(urns.probe)
From a desktop with reference RNS installed:
T282828
rnprobe urns.probe 4a1bβ¦
Both T383838full_name and T383838destination_hash are required: announces only carry a hash of the name, so the dot-name has to be known out of band. A successful probe prints T383838Valid reply received from <hash> with the measured RTT. The probe destination refuses link requests β it only signs PROOF replies. Other apps filter it out of their UIs by app_name.
Time sync (clock for pure-LoRa nodes)
A LoRa-only node has no WiFi/NTP and no battery-backed RTC, so its clock sits at T3838382000-01-01 and every message/announce it sends is stamped January 2000 (you'll see this on received images in MeshChat). Time sync fixes this by learning the real time from the mesh itself β every announce and every signed LXMF message already carries the sender's Unix timestamp.
T282828
Te6edf3CONFIG Tff7b72= Tb4b4b4{
Ta5d6ff"Ta5d6fftime_syncTa5d6ff"Tb4b4b4: Tb4b4b4{
Ta5d6ff"Ta5d6ffenabledTa5d6ff"Tb4b4b4: Tff7b72TrueTb4b4b4,
Ta5d6ff"Ta5d6fftrusted_nodesTa5d6ff"Tb4b4b4: Tb4b4b4[Tb4b4b4]Tb4b4b4, T8b949e# see modes below
Ta5d6ff"Ta5d6ffmin_sourcesTa5d6ff"Tb4b4b4: T79c0ff2Tb4b4b4, T8b949e# corroboration quorum (when trusted_nodes is empty)
Ta5d6ff"Ta5d6fftoleranceTa5d6ff"Tb4b4b4: T79c0ff120Tb4b4b4, T8b949e# seconds of allowed disagreement between peers
Tb4b4b4}Tb4b4b4,
Ta5d6ff"Ta5d6ffinterfacesTa5d6ff"Tb4b4b4: Tb4b4b4[Tff7b72.Tff7b72.Tff7b72.Tb4b4b4]Tb4b4b4,
Tb4b4b4}
Two modes:
β’ Authority β list one or more LXMF delivery hashes (hex, exactly as shown in MeshChat/Sideband) in T383838trusted_nodes. The first announce or signed message from a matching node sets the clock. Fastest, and corrects time on the very first packet heard.
β’ Corroboration β leave T383838trusted_nodes empty. The clock is set only once T383838min_sources distinct peers agree on the time within T383838tolerance seconds (the median is applied). No single node can move your clock, so you don't have to trust anyone in particular.
The sync runs once per power-on, only while the clock is still unset β it never re-adjusts mid-session. A reboot resets the RTC to 2000, and the node re-syncs from the next qualifying packet. After syncing, both outgoing message timestamps and announce timestamps are correct for the rest of the session.
The moment the clock syncs, the node automatically re-announces all its destinations: announces sent before sync carry a year-2000 emission timestamp and are rejected as stale replays by peers that knew the node from a previous boot β without the re-announce, a rebooted node would stay invisible to the mesh until its next periodic announce.
β The ESP32's internal RTC keeps time only while powered β it does not survive a full power cycle.
β For instant-correct time at boot with no peer audible, add a battery-backed RTC (e.g. DS3231 over
β IΒ²C).
Peripherals
Modular hardware drivers in T383838firmware/peripherals/ with a uniform contract:
β’ T383838init(...) β set up hardware
β’ T383838process(content) β handle an LXMF message or page-template query, return a response string or T383838None
βββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββ
β Module β Hardware β Triggers β
βββββββββββββββββΌββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββ€
β T383838bme280_sensor β BME280 IΒ²C sensor β Returns temperature / pressure / humidity wβ¦ β
β T383838sds011_sensor β SDS011 PM2.5 / PM10 UART sensor β Returns particulate matter readings when anβ¦ β
β T383838neopixel_led β WS2812 NeoPixel LED β T383838red, T383838green, T383838blue, T383838off β
β T383838gpio_control β Any GPIO pin β T383838<name> on, T383838<name> off, T383838<name>? β
β T383838adc_reader β ADC analog input (battery, β¦) β T383838<name>, or T383838sensor for all channels β returnβ¦ β
β T383838camera β OV2640 (camera firmware required) β Used by T383838example_camera_node.py β
βββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββββ
Wiring example
Peripherals are initialized at the top of T383838example_nomadnet_node.py (or T383838example_node.py). Uncomment what you have:
T282828
Tff7b72from T7ee787machine Tff7b72import Te6edf3PinTb4b4b4, Te6edf3SoftI2C
Te6edf3i2c Tff7b72= Te6edf3SoftI2CTb4b4b4(Te6edf3sclTff7b72=Te6edf3PinTb4b4b4(T79c0ff6Tb4b4b4)Tb4b4b4, Te6edf3sdaTff7b72=Te6edf3PinTb4b4b4(T79c0ff5Tb4b4b4)Tb4b4b4, Te6edf3freqTff7b72=T79c0ff100000Tb4b4b4)
Tff7b72import T7ee787peripheralsT7ee787.T7ee787bme280_sensor Tff7b72as T7ee787bme_sensor
Te6edf3bme_sensorTff7b72.Td2a8ffinitTb4b4b4(Te6edf3i2cTb4b4b4)
T8b949e# import peripherals.neopixel_led as neopixel_led
T8b949e# neopixel_led.init(pin=21)
T8b949e# import peripherals.gpio_control as gpio
T8b949e# gpio.init({"lamp": (2, "OUT")})
T8b949e# Battery: board-declared. Reads automatically IF the active board's preset in
T8b949e# lora_boards.py has a "battery" block. The XIAO ESP32-S3 has no BAT->ADC path
T8b949e# (Meshtastic disables battery on it too), so this stays off on that board.
Tff7b72import T7ee787peripheralsT7ee787.T7ee787adc_reader Tff7b72as T7ee787adc_reader
Tff7b72from T7ee787lora_boards Tff7b72import Te6edf3battery_config
Te6edf3_battery Tff7b72= Te6edf3battery_configTb4b4b4(Te6edf3CONFIGTb4b4b4)
Tff7b72if Te6edf3_batteryTb4b4b4:
Te6edf3adc_readerTff7b72.Td2a8ffinitTb4b4b4(Tb4b4b4{Ta5d6ff"Ta5d6ffbatteryTa5d6ff"Tb4b4b4: Te6edf3_batteryTb4b4b4[Ta5d6ff"Ta5d6ffpinTa5d6ff"Tb4b4b4]Tb4b4b4}Tb4b4b4, Te6edf3dividersTff7b72=Tb4b4b4{Ta5d6ff"Ta5d6ffbatteryTa5d6ff"Tb4b4b4: Te6edf3_batteryTff7b72.Td2a8ffgetTb4b4b4(Ta5d6ff"Ta5d6ffdividerTa5d6ff"Tb4b4b4, T79c0ff1.0Tb4b4b4)Tb4b4b4}Tb4b4b4)
T8b949e# import peripherals.sds011_sensor as sds011_sensor
T8b949e# sds011_sensor.init(uart_id=1, tx_pin=43, rx_pin=44)
Te6edf3active_peripherals Tff7b72= Tb4b4b4[Te6edf3bme_sensorTb4b4b4] Tff7b72+ Tb4b4b4(Tb4b4b4[Te6edf3adc_readerTb4b4b4] Tff7b72if Te6edf3_battery Tff7b72else Tb4b4b4[Tb4b4b4]Tb4b4b4)
Battery voltage is treated as board-fixed wiring: declare it once in the board's preset in T383838lora_boards.py as T383838"battery": {"pin": 1, "divider": 2.0} (the ADC GPIO and the T383838vbat = vpin Γ divider ratio), and T383838adc_reader picks it up automatically β T383838battery_config(CONFIG) resolves it and an inline T383838CONFIG["battery"] overrides. Boards with no batteryβADC path simply omit the block. Note: the Seeed XIAO ESP32-S3 (including the Wio-SX1262 "Meshtastic" kit) has no such path β there's no onboard divider, and Meshtastic itself ships that board with battery monitoring disabled (T383838BATTERY_PIN -1). So battery stays off unless you solder your own divider (T383838BAT+ β 2Γ200 kΞ© β T383838GND, midpoint to A0/GPIO1) and add the block with T383838divider: 2.0.
The SDS011 also needs T383838sds011_sensor.start() inside the async event loop (see T383838run_with_announce() in the example files) β that schedules a 5-minute duty cycle so the fan only runs during measurement.
SDS011 wiring (XIAO ESP32-S3)
ββββββββββββββ¬βββββββββββββββββββ
β SDS011 Pin β Connect to β
ββββββββββββββΌβββββββββββββββββββ€
β TX β GPIO 44 (T383838rx_pin) β
β RX β GPIO 43 (T383838tx_pin) β
β VCC (5 V) β VUSB β
β GND β GND β
ββββββββββββββ΄βββββββββββββββββββ
The SDS011 needs 5 V power (VUSB, only available with USB-powered boards). Its UART TX is 3.3 V-safe β no level shifter needed.
Active peripherals are also queried for the T383838{sensor} template variable in NomadNet pages. When multiple peripherals are active, all readings are shown.
Troubleshooting
WiFi won't connect
β’ Double-check T383838WIFI_SSID / T383838WIFI_PASS in T383838config.py. The ESP32-S3 only supports 2.4 GHz networks β a 5 GHz-only SSID will fail silently.
β’ Some routers separate 2.4 / 5 GHz under the same SSID; explicitly join the 2.4 GHz one if your router offers it.
Node doesn't appear in MeshChat
β’ The desktop running MeshChat and the Β΅Reticulum node must be on the same LAN subnet for UDP broadcast to reach across.
β’ If you have multiple NICs on the desktop (VPN, Docker bridge, virtual adapters), MeshChat may bind the wrong one. Disable interfaces you don't need.
β’ The example disables the WiFi access-point interface (T383838AP_IF) and turns WiFi power-management off β both are required to receive broadcasts. If you've stripped that out of T383838example_node.py, put it back.
T383838ImportError: no module named 'lora'
β’ Run T383838mpremote mip install lora-sx126x lora-sync once. This installs the SX126x driver from T383838micropython-lib to the device.
Native crypto module not loading (T383838ImportError: ed25519_fast)
β’ The T383838.mpy file in T383838firmware/lib/ must match your architecture: T383838*_xtensawin.mpy for ESP32-S3, T383838*_armv6m.mpy for RP2040.
β’ The T383838.mpy format is tied to a MicroPython version range. If your MicroPython is much newer than 1.22, see BUILDING_NATIVE_MODULES.md to rebuild.
β’ The system still works without the native module β just at ~4 s per message instead of <200 ms.
LoRa: no packets received
β’ Both ends must share T383838freq_khz, T383838sf, T383838bw, T383838coding_rate, and T383838syncword. A single mismatch and you'll receive nothing.
β’ For SX1262 boards with a TCXO (Wio-SX1262), T383838dio3_tcxo_millivolts must be set or the radio fails to init with T383838OpError 0x20.
β’ For SX1262 boards in TX-but-no-output situations, check the regulator mode and TX power. The T-Deck v1 specifically needs DC-DC regulator mode (see reticulum-tdeck for the workaround).
RP2040 crashes when E32 transmits
β’ The E32 draws ~120 mA at 20 dBm and that current spike can brown out the RP2040. Use T383838tx_power: 3 (10 dBm) or give the E32 its own supply with decoupling caps.
T383838OSError: -202 or T383838OSError: -116
β’ Usually a WiFi-stack issue from too many open sockets after long uptime. Reset the board.
Camera example fails with T383838ImportError: no module named 'camera'
β’ You're running standard MicroPython. The camera example requires the camera-enabled build β see Step 1.
Compatibility
Tested and confirmed working with:
β’ MeshChat β bi-directional announces, opportunistic messaging, delivery receipts
β’ Sideband β peer discovery, LXMF messaging
β’ NomadNet β peer discovery, LXMF messaging, page serving over Links
β’ Reference Reticulum (Python) β wire-compatible packets, announces, encryption, link handshake
β’ Reference LXMF β cross-validated message packing/unpacking, signature verification
β’ RNode (SX1276 / SX1278) β bidirectional LoRa, full split-packet support for the complete 500-byte MTU. Tested with Heltec Wireless Stick Lite V1 on 868 MHz.
β’ RNS transport servers β TCP client connectivity to remote transport hubs, automatic path learning from announces
Protocol behaviour tracks reference RNS 1.5.2. The 1.3.9 link and resource
safeguards are implemented here (see the Resource and link safeguards block
under Protocol details), as is the whole of 1.4.x that
applies to a leaf or relay node: dynamic link path re-balancing, interface
gravity, RTT-scaled keepalive and stale windows with the keepalive-reply
throttle, T383838max_request_size / T383838max_response_size, and out-of-window rejection
on T383838Channel (see Link path re-balancing below). Neither 1.4.x nor 1.5.x
changed the wire format, so older and newer peers interoperate either way.
RNS 1.5.0's headline is a priority-based inbound ingress-queue rewrite of
T383838Transport, built on OS threads and locks β there is no analogue for this
single-threaded T383838uasyncio port, and none is needed for interop: the port
already prioritises inline data, proof and link traffic over deferred announce
validation. The two items from that release that apply to a leaf or relay node
are implemented: an excessive-hop-count drop (T383838PATHFINDER_M = 128, rejected in
T383838Transport.packet_filter) and a constant-time HMAC comparison in
T383838Token.verify_hmac. Identity blackholing is supported (T383838Transport.blackhole());
the operator blackhole publish/subscribe lists and everything surfaced only
through T383838rnstatus are out of scope or opt-in, and none of it affects
interoperability.
RNS 1.5.1 and 1.5.2 likewise changed no wire format. Both are dominated by work
with no analogue on a single-threaded MCU β adaptive dataplane ingress/egress
control layered on the 1.5.0 queue rewrite, T383838BackboneInterface transmit
buffers, live profiling, and T383838rnstatus diagnostics β or by fixes already
covered here (the 1.5.2 resource-cancel guard cannot occur, since cancellation
routes through T383838resource_concluded and the link's T383838cancel_*_resource already
check membership; bz2 compression already falls back to uncompressed). The two
frame-validation hardening checks that apply to a leaf or relay node are
implemented: T383838Packet.unpack rejects a zero-length data field, and
T383838Transport.packet_filter drops an announce frame larger than the MTU.
Out of scope for an MCU port: T383838BackboneInterface flap-blocking, interface
discovery, I2P, shared-instance/tunnel interfaces, and the T383838rnsh utility.
β If you run an T383838rnsh listener (any platform), update it to RNS 1.3.9: that release patches a
β critical vulnerability where a command could be started on a session that never completed
β identity authorisation. This port contains no listener, so it is not affected β but a listener
β elsewhere on your mesh is.
Performance on ESP32-S3
With the native crypto module (default, recommended)
βββββββββββββββββββββββββββββββ¬ββββββββββ
β Operation β Time β
βββββββββββββββββββββββββββββββΌββββββββββ€
β Ed25519 sign β 12 ms β
β Ed25519 verify β 18 ms β
β X25519 key exchange β 13 ms β
β Receive + decrypt message β ~50 ms β
β Total message round-trip β <200 ms β
β IFAC sign/verify per packet β ~15 ms β
βββββββββββββββββββββββββββββββ΄ββββββββββ
Pure-Python fallback (no T383838.mpy modules)
βββββββββββββββββββββββββββββ¬βββββββ
β Operation β Time β
βββββββββββββββββββββββββββββΌβββββββ€
β Receive + decrypt message β ~2 s β
β Verify Ed25519 signature β ~2 s β
β Sign + send proof β <1 s β
β Total message round-trip β ~4 s β
βββββββββββββββββββββββββββββ΄βββββββ
The native C module (Monocypher-based) is ~160Γ faster than pure-Python Curve25519. Pre-built T383838.mpy files for ESP32-S3 and RP2040 ship in T383838firmware/lib/ β they're loaded automatically when present. If you accidentally don't upload them, everything still works, just slowly.
Limitations
β’ MicroPython only β no CPython/desktop support. Uses T383838uhashlib, T383838ucryptolib, T383838uasyncio, T383838micropython.const directly.
β’ LXMF message size β single-packet opportunistic messages up to ~295 bytes content. Larger messages (up to 16 KB) use Link-based DIRECT delivery via Resource transfer, including through multi-hop transport chains.
β’ No propagation node β cannot store-and-forward messages for offline peers.
β’ On-demand path resolution β when sending to a peer it has no route to (e.g. a transport-distant node right after a reboot), the node issues a Reticulum path request and delivers the message once the route is learned, instead of silently dropping it. Replies also reuse an already-open link when present.
β’ Pure-Python crypto fallback β ~4 s message round-trip without the native module. With native module: <200 ms.
Roadmap
Potential areas for expansion:
β’ Propagation node β store-and-forward for offline peers
β’ More sensor integrations β additional peripheral drivers
Protocol details
This section is the deep dive β you don't need any of it to use the project, but it's here for anyone interested in how it interoperates with reference Reticulum.
<details>
<summary><b>Message flow (MeshChat β ESP32-S3)</b></summary>
T282828
MeshChat ESP32-S3 (Β΅Reticulum)
β β
ββ LXMF announce βββββββββββββββββββΊ β Validates Ed25519 signature
β β Stores peer identity & display name
β β
β βββββββββββββββββββ LXMF announce ββ€ Sends own announce (+ periodic re-announce)
β Peer appears in β
β network visualizer β
β β
ββ Encrypted LXMF message βββββββββΊ β X25519 ECDH decrypt
β (e.g. "green") β Unpack msgpack payload
β β Verify Ed25519 signature
β β Set NeoPixel color / echo reply
β β
β βββββββββββββββββ Delivery proof βββ€ Sign packet hash with Ed25519
β Shows "delivered" β Send PKT_PROOF back
β β
β βββββββββββ Echo reply (LXMF) βββββ€ Encrypt + sign reply message
β Receives "Echo: green" β Send via opportunistic delivery
</details>
<details>
<summary><b>LXMF wire format</b></summary>
βββββββββββββββββββββ¬βββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββ
β Field β Size β Description β
βββββββββββββββββββββΌβββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββ€
β Destination hash β 16 bytes β Truncated SHA-256 of destination β
β Source hash β 16 bytes β Truncated SHA-256 of source β
β Ed25519 signature β 64 bytes β Signs dest + source + payload + message_id β
β Payload (msgpack) β variable β T383838[timestamp, title, content, fields] β
βββββββββββββββββββββ΄βββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββ
Total overhead: 112 bytes. Content capacity in a single encrypted packet: ~295 bytes.
Announces carry msgpack-encoded app data so peers know the node's display name:
T282828
T8b949e# Wire format: msgpack [name_bytes, stamp_cost]
T8b949e# Example: [b"ESP32s3", None]
Ta5d6ffbTa5d6ff'Tffea00\x92Tffea00\xc4Tffea00\x07Ta5d6ffESP32s3Tffea00\xc0Ta5d6ff'
</details>
<details>
<summary><b>NomadNet link handshake</b></summary>
T282828
NomadNet Client ESP32-S3 (Β΅Reticulum)
β β
ββ Link Request (X25519 pub key) ββββββΊ β Generate ephemeral X25519 keypair
β β ECDH shared secret β HKDF β AES-256 Token
β β
β ββββββ Link Proof (signature + pub) βββ€ Sign with destination Ed25519 identity
β β
ββ RTT (encrypted) βββββββββββββββββββΊ β Link ACTIVE
β β
ββ Page Request (encrypted RPC) βββββββΊ β Decrypt, look up handler by path hash
β β Read .mu file, substitute variables
β βββββββββ Page Response (encrypted) βββ€ Encrypt and send
Each link consumes ~350 bytes of RAM. Up to 4 concurrent links are supported (T383838MAX_ACTIVE_LINKS=4). Idle links are cleaned up after 12 minutes.
</details>
<details>
<summary><b>Outbound link establishment on half-duplex LoRa (LRRTT resend + delivery retry)</b></summary>
Reference RNS marks a responder link established β and starts accepting resource
transfers on it β only when the initiator's RTT packet (LRRTT) arrives. That
packet is sent exactly once, and on a half-duplex LoRa mesh it is unusually easy to
lose: a node typically opens its reply link seconds after proving an inbound
message, while the relay upstream is still transmitting the sibling link's traffic β
its radio is deaf mid-TX and the LRRTT dies. The failure is silent and deceptive:
the half-established peer still answers keepalives (raw 1-byte frames, processed
regardless of link state) but discards every resource advertisement, so the link
looks alive while nothing ever delivers.
Β΅Reticulum hardens both layers on the initiator side:
β’ LRRTT resend (T383838link.py) β the RTT payload is kept and re-sent (up to 4Γ,
every 4 s) until the peer sends anything that decrypts, which proves its side
of the link completed establishment (each resend re-encrypts with a fresh IV, so
transport dedup never drops it; a peer that was already established just re-fires
its idempotent established-callback).
β’ DIRECT delivery retry (T383838lxmf.py) β if the link dies before the message got
through (establishment timeout: lost LR or proof) or the resource transfer fails,
the delivery is re-attempted on a fresh link, 3 attempts total β matching
reference LXMF's retry behavior.
</details>
<details>
<summary><b>Resource and link safeguards (parity with RNS 1.3.9)</b></summary>
A link peer is untrusted input: it can send a malformed resource advertisement,
re-identify mid-session, or vanish mid-transfer. Reference RNS 1.3.9 tightened
these paths, and this port implements the equivalents β with an MCU's much
smaller margin for error in mind.
β’ Advertisement validation β every advertisement is parsed and validated
inside one guarded block: msgpack errors, missing fields, wrong types,
negative or absurd sizes are rejected before anything is allocated. On a
desktop an unchecked size claim wastes memory; on an ESP32 it is an immediate
out-of-memory. An advertisement that cannot be processed at all tears the link
down rather than leaving it looping on bad input.
β’ Pre-send link check β a resource verifies its link is still T383838ACTIVE
before every advertisement, part, request and proof. A closing link nulls its
resources' references, so an unguarded watchdog send would raise inside the
event loop.
β’ Cancellation signalling β cancelling tells the peer: T383838RESOURCE_ICL from
the sender, T383838RESOURCE_RCL from the receiver (the receiver-side signal is new
in 1.3.9). Without it the far end keeps re-advertising or re-requesting until
its own timeout β minutes of wasted airtime on half-duplex LoRa. A cancel that
arrives from the peer is never echoed back.
β’ Identity binds once β a second T383838identify on an established link is
ignored, so anything authorising on the identified identity cannot have its
authorisation subject swapped mid-session.
β’ HDLC frame validation (TCP interface) β frames shorter than a packet
header, or that overflowed the buffer mid-flight, are dropped instead of
handed to routing as truncated packets.
Covered by T383838firmware/tests/test_resource_safeguards.py.
</details>
<details>
<summary><b>Link path re-balancing and keepalives (parity with RNS 1.4.x)</b></summary>
A link request and the proof that answers it do not always travel the same
number of hops β a route can shorten or lengthen between the two, and on a mesh
with several possible paths they can simply differ. Both ends check the proof's
hop count, so a mismatch used to mean the link never came up at all.
β’ Re-balancing at a relay β when a transit link-request proof arrives with a
hop count other than the one recorded for that link, the relay verifies the
proof signature and then adopts the new count, in both the link table and the
path table, instead of dropping the proof. The signature check is mandatory
here: this rewrites routing state, so an unverifiable proof (no native
Ed25519, or T383838strict_lr_validation off) is still dropped β a failed link beats
an unauthenticated hop rewrite.
β’ Re-balancing at the initiator β the same correction is applied to the path
table once our own link goes active. No extra crypto is spent: the link only
reaches that state after the peer's signature over our link id has been
verified.
β’ Keepalive on outbound silence β what stales a link at the far end is how
long since we transmitted, not how long since we heard. An initiator that
only receives (a peer streaming to it) used to fall silent and get torn down
mid-stream; it now probes when either direction has been quiet.
β’ RTT-scaled windows β the RTT the initiator measures is carried in the
handshake and now sizes the receiver's keepalive and stale windows
(T383838clamp(rtt Γ 360/1.75, 5, 360), stale = twice that). LoRa clamps back to the
360 s / 720 s pair this port used unconditionally before; a fast link drops to
roughly 20 s / 41 s, so a dead TCP link is reaped in under a minute instead of
twelve. An absent or unusable value keeps the old defaults.
β’ Keepalive reply throttle β with both ends deriving that window from the
same RTT, a T3838380xFF probe is answered only if we have been quiet for it.
Anything transmitted inside the window already proved us alive, and on
half-duplex LoRa the saved frame is one that would have gone out exactly when
the channel is busiest.
β’ Channel window β a message sequence past the far edge of the receive
window is rejected rather than buffered forever behind a gap that can never be
filled.
Request and response size limits β T383838destination.set_max_request_size(n)
caps what a destination's request handlers will accept, and
T383838link.request(..., max_response_size=n) caps what comes back. Both refuse
before buffering: an oversized single-packet request is dropped before it is
unpacked, and an oversized resource is cancelled with an T383838RCL before a single
part transfers, so the sender stops immediately rather than retrying for
minutes. Without a limit the stack's own 16 KB T383838MAX_RESOURCE_SIZE ceiling still
applies β worth lowering on a node with tens of KB of free heap, especially over
TCP where the negotiated link MTU reaches 16 KB.
Covered by T383838firmware/tests/test_transport.py, T383838test_link_request.py,
T383838test_resource_safeguards.py and T383838test_channel.py, and verified on an ESP32-S3
(T-Deck) against real native Ed25519.
</details>
<details>
<summary><b>SX1262 LoRa β RNode split-packet protocol</b></summary>
The LoRa interface implements the same split-packet framing as RNode firmware, enabling transparent interop with RNode devices and support for Reticulum's full 500-byte MTU over LoRa's 255-byte frame limit.
Every LoRa frame carries a 1-byte RNode header:
ββββββββ¬βββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββ
β Bits β Field β Description β
ββββββββΌβββββββββββββΌβββββββββββββββββββββββββββββββββββββββββββββββ€
β 7β4 β Sequence β Random 4-bit value for matching split halves β
β 0 β FLAG_SPLIT β Set when packet is split across 2 frames β
ββββββββ΄βββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββββ
β’ Single frame (data β€ 254 bytes): T383838[header] [data] β max 255 bytes
β’ Split packet (data 255β508 bytes): two frames with the same header byte (same sequence + FLAG_SPLIT), back-to-back:
β’ Frame 1: T383838[header] [first 254 bytes] = 255 bytes
β’ Frame 2: T383838[header] [remaining bytes]
The receiver matches split frames by sequence number and reassembles them into a complete Reticulum packet. Stale fragments are discarded after 15 seconds.
The T383838lora-sx126x MicroPython driver sends and receives bytes faithfully β the RNode header byte is the first byte returned by T383838poll_recv() on RX and the first byte written by T383838send() on TX. No FIFO offset workarounds are needed.
Packets with bit 7 set in the Reticulum flags byte (IFAC-tagged) are validated if IFAC is configured on the receiving interface, or dropped if IFAC is not configured. This matches reference Reticulum behavior.
</details>
<details>
<summary><b>ESP32-S3 socket workarounds</b></summary>
The UDP interface includes several workarounds for ESP32-S3 MicroPython lwIP quirks:
β’ Single TX/RX socket β saves ~280 bytes IDF heap vs two sockets.
β’ T383838settimeout(0) re-asserted after every T383838sendto() β ESP32-S3 lwIP bug: T383838sendto() corrupts the socket's non-blocking state. Without this, T383838recvfrom() silently blocks after the first send, freezing the async event loop.
β’ No T383838select.poll() β T383838poll(0) doesn't reliably detect incoming UDP on ESP32-S3 lwIP. Uses direct non-blocking T383838recvfrom() + T383838except OSError instead.
β’ RX socket watchdog β if the interface previously received traffic but hasn't for 60 seconds, the socket is closed and recreated.
β’ WiFi power management disabled β T383838wlan.config(pm=0) is required to receive broadcast UDP packets.
β’ AP_IF deactivated β dual-interface mode routes broadcast packets to AP instead of STA, preventing UDP broadcast reception.
</details>
<details>
<summary><b>Native crypto module (.mpy details)</b></summary>
The native C module wraps Monocypher compiled to native machine code, distributed as a T383838.mpy file alongside the Python code (no firmware recompile needed).
βββββββββββββββββββ¬ββββββββββββββ¬βββββββββββ¬ββββββββββ
β Operation β Pure Python β Native C β Speedup β
βββββββββββββββββββΌββββββββββββββΌβββββββββββΌββββββββββ€
β Ed25519 sign β 2 000 ms β 12 ms β 166Γ β
β Ed25519 verify β 2 000 ms β 18 ms β 111Γ β
β X25519 exchange β 1 400 ms β 13 ms β 107Γ β
βββββββββββββββββββ΄ββββββββββββββ΄βββββββββββ΄ββββββββββ
T383838.mpy files are version 6 (MicroPython 1.19+) and architecture-specific:
ββββββββββββββββββββββββββββββ¬ββββββββββββββββββββ¬ββββββββββββββββββ
β File β Architecture β Devices β
ββββββββββββββββββββββββββββββΌββββββββββββββββββββΌββββββββββββββββββ€
β T383838ed25519_fast_xtensawin.mpy β Xtensa (windowed) β ESP32-S3 β
β T383838ed25519_fast_armv6m.mpy β ARM Cortex-M0+ β RP2040 (Pico W) β
ββββββββββββββββββββββββββββββ΄ββββββββββββββββββββ΄ββββββββββββββββββ
Both ship pre-built in T383838firmware/lib/ and are loaded automatically. If a future MicroPython version changes the T383838.mpy format, see BUILDING_NATIVE_MODULES.md for cross-compilation instructions.
</details>
<details>
<summary><b>Native BZ2 module</b></summary>
Reference RNS always compresses Resource transfers with bz2. The native C module provides both compression and decompression, producing stdlib-compatible bz2 output that interoperates with reference RNS.
β’ Decompression: ~100Γ faster than pure Python (~2 ms vs ~200 ms for 1 KB). Falls back to pure Python if native module is missing.
β’ Compression: ~500 ms for 1 KB on ESP32-S3. Reduces text payloads by 60β80% (e.g. 1 253 B β 394 B). Only available with native module β without it, Resources are sent uncompressed (which is valid).
ββββββββββββββββββββββββββ¬ββββββββββββββββββββ¬ββββββββββββββββββ
β File β Architecture β Devices β
ββββββββββββββββββββββββββΌββββββββββββββββββββΌββββββββββββββββββ€
β T383838bz2_fast_xtensawin.mpy β Xtensa (windowed) β ESP32-S3 β
β T383838bz2_fast_armv6m.mpy β ARM Cortex-M0+ β RP2040 (Pico W) β
ββββββββββββββββββββββββββ΄ββββββββββββββββββββ΄ββββββββββββββββββ
</details>
Project structure
T282828
uP-reticulum/
βββ README.md
βββ images/ # README assets
βββ tools/ # Build tools, hardware docs, support files
β βββ natmod/ # Native C modules (ed25519_fast, bz2_fast)
β βββ camera/ # Camera board pinout, test scripts
β βββ ebyte/ # E32/E220 datasheets
β
βββ firmware/ # β Upload contents to microcontroller root
βββ example_node.py # LXMF messaging node with NeoPixel control
βββ example_nomadnet_node.py # NomadNet page-serving node
βββ example_camera_node.py # OV2640 camera node (ESP32-S3-CAM)
βββ example_sensor.py # Sensor client with deepsleep
βββ example_proxy.py # USB serial β LoRa chat bridge (RP2040)
βββ config.py # Node configuration (WiFi, interfaces)
βββ lib/ # Native C modules (.mpy) β auto-loaded
βββ pages/ # NomadNet micron-format pages
βββ files/ # Downloadable files served over Links
βββ sensors/ # Low-level sensor drivers
βββ peripherals/ # Modular hardware drivers (LED, GPIO, ADC, sensors, camera)
βββ urns/ # The Reticulum stack itself
βββ reticulum.py # Core init, config, async event loop
βββ identity.py # Identity, key generation, announce validation
βββ destination.py # Addressing, encryption, announces
βββ packet.py # Packet framing, proof generation, receipts
βββ transport.py # Directed routing / relay, path tables, announce propagation
βββ link.py # Reticulum Links (ECDH, RPC)
βββ resource.py # Resource protocol (segmented data)
βββ lxmf.py # LXMF message format, LXMRouter
βββ interfaces/ # UDP, TCP, Serial, E32, SX1262
βββ crypto/ # X25519, Ed25519, AES, HKDF, HMAC, SHA, Token
License
MIT
Acknowledgments
Served by rngit 1.5.2 - Generated in 0.1s